hyp sync: one export verb, and a confirmed way out of the first-sync hold (LLP 0101/0100) - #419
Conversation
…hold (LLP 0101/0100)
An onboarding session could not show data on the server. The enrolling
login writes a first-sync hold marker (LLP 0101), and the driver returns
`{ sinks: [], held: 'first_sync_hold' }` before it ever consults
`force: true`, so `hyp sink force` printed "no sinks instantiated;
nothing to do" - which is the opposite of the truth - and nothing could
end the window early. On an evening enrollment the 4-hour floor rolls the
deadline to the *next* day's 11:59pm, so "wait until midnight" could mean
28 hours.
LLP 0101 #no-release rejected exactly this verb, for three reasons. Two
survive: the printed message stays unconditionally true (reworded to "no
later than"), and release-on-review-completion still needs a completion
signal from the skill that a finished skill cannot honestly give. The
third - "costs only latency on a machine that was not forwarding
yesterday either" - is what this concedes. On an attended onboarding the
hold blocks the demonstration that the product works, and the window is
sized for a review (hypaware-privacy) too slow to run in the meeting the
hold was scoped to. #which holds the attended lane while unattended
`hyp join` forwards immediately, which is backwards for that case.
LLP 0069 R6 ("not forwarded, even once") is untouched: R6 forbids a
*silent* first forward, and an explicit confirmation naming the
destination is the consent it exists to obtain. The daemon never clears
the marker, so an unattended machine still waits out the full window.
`hyp sync [instance] [--yes] [--dry-run]` replaces `hyp sink force`
rather than aliasing it: both were one `driver.tick({force: true})`, and
a second spelling preserves the confusion it was introduced to fix
(the #399 doctrine). It always confirms - one rule beats a prompt whose
appearance the user has to predict - and prints what it is about to send
first: every destination named, annotated off-machine only on the
evidence of an http(s) URL and on-machine only on the evidence of a path,
plus the directories being withheld. "Are you sure?" with nothing to be
sure about is a keystroke, not a decision. `hyp sink maintain` stays;
snapshot expiration is not an export tick.
The three surfaces that print the deadline (login message, wizard privacy
narration, `hyp status`) now name the verb, and the formatted deadline
carries its time zone - ECMA-402 forbids mixing timeZoneName with
dateStyle/timeStyle, so the zone comes from a second formatter.
`purge` and `report delete` had grown one copy each of the same y/N
prompt; all three now share src/core/cli/confirm.js with their question
text unchanged.
LLP 0101 #no-release is amended with the original preserved as a quote,
and LLP 0100 R2 ("No export tick may run before the deadline") with it -
it was flatly false once this lands. R1 now requires the zone and the
release verb.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round 1 -
|
philcunliffe
left a comment
There was a problem hiding this comment.
Reviewed at e25b45b. Full record above.
One blocker, reproduced against the real runSync and a real createSinkDriver.
hyp sync <instance> releases the driver-wide first-sync hold after showing a plan built from the filtered handles. sync.js:69 filters to the named instance, :83 builds destinations from that filtered set, and :115 clears the hold unconditionally on instance.
Concretely: a user with a central forward sink and a local parquet sink runs hyp sync parquet to check the local export works. The prompt names only parquet and says "(stays on this machine)". They answer y. The hold marker is gone, and the next daemon tick forwards the full backfilled history to the org server with no prompt and nothing on screen. They consented to a local file write and paid for it with their forward hold.
LLP 0101 #hold names this exact failure as the reason the scope is driver-wide: "one missed forward hold is a permanent leak."
The gap that let it through is visible in the test file: an instance argument ticks only that sink (sync-command.test.js:256) runs with no hold marker written, so instance-plus-hold is the one combination never exercised.
The rest is in good shape. The consolidation is clean (no stragglers referencing hyp sink force anywhere, prompts preserved character for character). The consent gate is well built on the unscoped path: no env var, config key, preset, or non-interactive default, and a stray keypress cannot satisfy it. And the highest-stakes question comes back clean: hyp sync performs the same driver.tick({ force: true }) as the daemon, withholding happens downstream at a seam this PR does not touch, and the local_only_export_withhold smoke passes on this head. No local-only leak.
Five non-blocking findings are in the record. Two I would flag as more than nits: a failed hold-clear currently turns a confirmed sync into a silent exit 0 with nothing exported, and --yes releases a live hold in a way the clause titled "Release only by confirmed, attended request" does not license, so the doc and the code disagree as landed.
Advisory as always: neutral does not ready or merge contributor PRs.
Six findings from neutral's review of e25b45b. 1. BLOCKER. `hyp sync <instance>` released the driver-wide hold after a plan built from the one named handle. `hyp sync parquet` on a machine with a central sink showed "parquet /home/u/exports (stays on this machine)", and a `y` there ended the window for `central` too - the next daemon tick then forwarded the whole backfilled history with nothing on screen. That is the silent first forward the hold exists to prevent (LLP 0101 #hold: "one missed forward hold is a permanent leak"), and it falsified this PR's own headline property. A scoped run now refuses while a hold is live and says why. Releasing is all-or-nothing because the hold is; rendering the full destination list from a command that ticks one sink would have been the other option, but "I asked for one sink and it released everything" is a surprise the refusal avoids entirely. 2. A failed unlink turned a confirmed sync into a silent exit 0: the marker survived, the driver held the tick, and the loop over `report.sinks` printed nothing. That is the defect this PR opens by describing, reintroduced one path over. The clear is now checked, the marker re-read, and `report.held` inspected after the tick; each is an error with the marker path, not a quiet success. 3. The release emitted no telemetry, and clearing the marker destroys the only on-disk evidence it happened, so nothing afterwards distinguished "the window expired" from "somebody ran hyp sync". Logs `sync.first_sync_hold_released` with the deadline, how early it was, and the destination counts. 4. `--yes` released a live hold, which the clause titled "Release only by confirmed, attended request" does not license. It now refuses while held. `--yes` still works for ordinary syncs; what it must not buy is somebody's review window. 5. Three prose surfaces still said the deadline was the only way the window ends: README, and the hypaware-privacy skill in both the claude and codex copies - the skill being the surface LLP 0100 designates for the review itself, telling a user mid-review that their window cannot end early. 6. The plan printed the raw server URL that R1a was landed to remove. R1a binds the login's surfaces by its text, but its reason is about terminals, and this prompt appears at the same onboarding moment: servers are now named from `query.remotes` by origin, with the `hyp remote list` pointer, falling back to the host when no name is configured. Both refusals run before the plan renders: a scoped plan is the misleading artifact finding 1 is about, and printing "syncing now ends it early" ahead of "you cannot end it this way" reads as a contradiction. `--dry-run` is exempt from both - it sends nothing, so showing a held machine what one destination would export is information, not consent. LLP 0101 #no-release and LLP 0100 R2 record the two shapes that cannot release and why, so the constraint is not left living in code comments. Tests: the instance-plus-hold combination the review identified as never exercised, plus --yes-plus-hold, an unclearable marker, and the two naming paths. 16 in the file, 2760 in the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks - the blocker was real, and the reproduction was exact. All six findings are addressed in d61f99d. 1. BLOCKER - fixedA scoped run now refuses while a hold is live: Exit 2, marker intact, nothing exported. Verified end-to-end through the real CLI with a real materialized sink, not only in the unit test. I took the first of your three suggested fixes rather than rendering the plan from Your test now exists ( 2, 3, 4 - fixed
5 - fixedREADME plus the 6 - fixedServers are named from I agree R1a does not bind this surface literally, and rather than claim an exemption I applied its reason, which transfers whole. The Ordering change you did not ask forBoth refusals run before the plan renders. A scoped plan is the misleading artifact finding 1 is about, and printing "Syncing now ends it early" immediately above "you cannot end it this way" read as a contradiction. DocsLLP 0101 Verification
Your closing read was right: the instance filter and the driver-wide hold were each correct alone and only conflicted where they met. Worth saying that the local-only verification was the most useful part of the review to have on record. |
Review round 2 -
|
| Attempt | Result |
|---|---|
hyp sync parquet (TTY, answers y) |
exit 2, marker intact, exported 0 |
hyp sync parquet --yes |
exit 2, marker intact, exported 0 |
hyp sync parquet -y (alias) |
exit 2, marker intact, exported 0 |
hyp sync --instance parquet (flag form, not positional) |
exit 2, marker intact, exported 0 |
hyp sync --yes (unscoped, no TTY) |
exit 2, marker intact, exported 0 |
hyp sync --yes (unscoped, on a TTY) |
exit 2, marker intact, exported 0 |
hyp sync (unscoped, no TTY, no --yes) |
exit 2, marker intact, exported 0 |
hyp sync (unscoped, TTY, answers n) |
exit 0, marker intact, exported 0 |
hyp sync parquet --dry-run |
exit 0, marker intact, exported 0 (see finding 2) |
hyp sync --dry-run --yes |
exit 0, marker intact, exported 0 |
hyp sync solo where solo is the only sink |
exit 2, marker intact - refusal still fires |
hyp sync (unscoped, TTY, y) - the sanctioned path |
exit 0, marker removed, both sinks exported |
The regression risk a refusal introduces got the same treatment, since a refusal that outlives the window would break the verb permanently. It does not: the gate keys on a live deadline, and readFirstSyncDeadline returns null for absent, expired, corrupt, and unreadable. Verified all four shapes export normally (no marker: exit 0, only the named sink ticks; expired marker at deadline minus 60s: exit 0, exports, marker opportunistically unlinked; corrupt marker: exit 0, fail-open; usage-policy/ at mode 000: exit 0, no spurious refusal).
Also confirmed clearFirstSyncHold has exactly one caller repo-wide, no other command removes usage-policy/ state, and hyp sink force is gone from the registry, so there is no second door into driver.tick.
On your choice of fix: taking the refusal over rendering the plan from allHandles was the better of the two. "Releasing is all-or-nothing" follows directly from the hold being driver-wide, which is what the user was told at enrollment, so it is a rule they can hold in their head rather than a surprise they have to notice in a plan.
Round 1 fix verification
| # | Item | Result |
|---|---|---|
| BLOCKER | scoped run must not clear the driver-wide hold | PASS - refused before the plan renders, all 5 scoped variants |
| 2 | check the clear return and report.held |
PASS, with finding 1 as a caveat on the return check |
| 3 | telemetry on the release | PARTIAL - event exists and is well attributed, but dropped in a default install (finding 3) |
| 4 | --yes must not release a live hold |
PASS, exit 2, marker intact, holds for -y too |
| 5 | README plus both skill copies | PASS - both hypaware-privacy copies byte-identical in the changed lines |
| 6 | raw server URL on the consent surface | PASS, fixed this round - describeDestination now routes through nameServer, yielding the 'prod' server plus the hyp remote list pointer, pinned by assert.doesNotMatch(text, /https:\/\//) |
Findings
1. NON-BLOCKING, and the one I would fix. !cleared treats "the marker was already gone" as "the unlink failed", so a completed release can exit 1 with a false permissions error and send nothing. sync.js:166-176:
const cleared = await clearFirstSyncHold({ stateDir })
const stillHeld = await readFirstSyncDeadline({ stateDir })
if (!cleared || stillHeld !== null) {clearFirstSyncHold's own JSDoc says it returns "true when a marker was removed, false when none was present", and its catch swallows every error, so the boolean cannot distinguish absent from EPERM. The stillHeld !== null half is the correct test; the !cleared half fires on a marker that legitimately vanished.
Reproduced: a user runs hyp sync at 11:58pm, reads the plan, answers y at 12:00am. In between, the deadline passed and the daemon's own readFirstSyncDeadline opportunistically unlinked the expired marker. Result is exit 1, nothing exported, and:
hyp sync: could not end the review window - the hold marker could not be removed
/.../usage-policy/first-sync-hold.json
Nothing was sent. Check the file's permissions and re-run.
The file does not exist, the permissions are fine, and the window is over. The fail direction is safe and a re-run succeeds immediately, so this is not a privacy defect. But it is a wrong error on the one surface that has to be trustworthy, and LLP 0101's new text ("A release that cannot be completed is an error") does not license calling a completed release a failure. Dropping !cleared || and keeping stillHeld !== null fixes it.
2. NON-BLOCKING. A scoped --dry-run under a live hold prints the exact contradiction the refusal ordering was designed to avoid. sync.js:109 (&& !dryRun) and :131-132. Verified output of hyp sync parquet --dry-run while held, with two sinks configured:
hyp sync: 1 destination
parquet /home/u/exports (stays on this machine)
FIRST SYNC - nothing has left this machine yet
Syncing now ends it early and sends your backfilled history.
The user sees a one-destination plan and is told syncing now ends the window, then hyp sync parquet refuses with "you cannot end it this way". Your own comment at :92-94 says the refusals are ordered before the plan precisely because that reads as a contradiction; the --dry-run exemption reintroduces it verbatim. Nothing leaks (marker intact, exit 0), so this is messaging rather than a gate. Suppressing the "ends it early" line, or naming the unscoped command, when dryRun && instance closes it.
3. NON-BLOCKING. The new release audit record is dropped in a default install, so finding 3 is half closed. log.info('sync.first_sync_hold_released', ...) at :181-188 reaches a provider only when HYP_DEV_TELEMETRY=1 or an OTLP endpoint is set; both default off. Measured end to end: prod mode gives loggerProviderInstalled=false, filesOnDisk=0, recordsMentioningTheRelease=0; dev mode gives 1 record. Since the clear unlinks the marker, an ordinary user's machine retains zero evidence the window was ended early rather than expiring. This is the codebase-wide pattern (purge.js:94 has the same property), so it is not a regression you introduced, and the attribute set is good when it does land.
4. NON-BLOCKING, test coverage. Three paths this round turns on are unpinned. hyp sync <instance> --yes under a live hold has no test (the scoped case at :229 uses TTY plus y, the --yes case at :255 is unscoped) - behaviour is correct, but the two refusals are separate ifs and either could be reordered without a red test. No test drives runSync with an expired marker, which is exactly the permanent-breakage risk a refusal introduces; I verified it manually in three shapes. And nothing covers finding 1's already-gone-marker path, since the clear-fails test covers only the EPERM half.
5. NON-BLOCKING, nit. sync.js:122-126 tells a --yes user to "Run hyp sync from a terminal", but the refusal fires on --yes regardless of TTY (verified on a TTY: exit 2). The actionable instruction is "re-run without --yes".
Conventions
Clean. No semicolons, zero U+2014 on any added line, JSDoc only, no @typedef, no inline import() types (the await import('../sinks/driver.js') is a value import), and repo-root-anchored type-import specifiers. All four @ref anchors resolve. The LLP 0100 R2 and LLP 0101 #no-release edits landed in d61f99d alongside the code, and the doc text now matches the implemented behaviour, including both refusals and the failed-unlink rule.
Verification run
npm test at this head: 2761 tests, 2752 pass, 8 fail. At base 701982c: 2745 tests, 2736 pass, 8 fail, identical names. The count did not grow and test/core/leave-command.test.js is still the only failing file. npm run typecheck exit 0. node --test test/core/sync-command.test.js: 16/16. Smokes local_only_export_withhold and local_parquet_export both pass, the latter driving hyp sync --yes through real CLI dispatch.
Approving. The blocker is closed, the regression it could have introduced is not there, and the doc now matches the code. Finding 1 is the one I would still fix, and it is a two-token change. Merging remains your call: neutral does not ready or merge contributor PRs.
|
Round 2 reviewed at Approving. The blocker is genuinely closed. I went looking for a way around the refusal rather than taking it at face value: twelve release paths executed against the real I also went after the regression a refusal invites, since one that outlived the window would break the verb permanently. It does not: the gate keys on a live deadline, and absent, expired, corrupt, and unreadable all read as no hold. Verified all four export normally. Taking the refusal over rendering the plan from Five non-blocking findings are in the record. The one I would still fix is a two-token change: Also worth a moment: no test drives the verb with an expired marker, which is exactly the permanent-breakage shape a refusal introduces. I verified it by hand in three shapes, but nothing pins it. Advisory as always: neutral does not ready or merge contributor PRs. |
#419) (#427) * Guard the first-sync deadline's time zone (#416) The rendering defect reported in #416 was already fixed by #419 (0253fcc): formatFirstSyncDeadline appends the zone from a second Intl formatter, since ECMA-402 forbids mixing timeZoneName with dateStyle/timeStyle. What the issue also asked for is missing, and the formatter had no test coverage at all. This adds that regression guard, so the four consent surfaces that render the deadline (LLP 0100 R1 and R9, LLP 0101) cannot silently regress to a bare wall-clock time. Test-only: no production code changes. The assertions are pinned to explicit zones rather than the host's, and the cross-zone case compares against the zone token the runtime itself names, so it holds in any locale. The exact-string case is skipped off an English host. Reverted to the pre-#419 formatter body, both cases fail with 'Jul 23, 2026, 11:59 PM' where 'Jul 23, 2026, 11:59 PM PDT' is expected. Co-Authored-By: Claude <noreply@anthropic.com> * review: skip the pinned-string deadline case off en-US, not off non-English The pinned expectation 'Jul 23, 2026, 11:59 PM PDT' is en-US-specific formatting. Other English locales (en-GB, en-CA, en-AU) passed the old locale.startsWith('en') guard and then failed the assertion. Tighten the guard to the exact locale the strings encode. --------- Co-authored-by: neutral-loop <neutral-loop@localhost> Co-authored-by: Claude <noreply@anthropic.com>
From the onboarding feedback session: "We need a working force sync command so the user doesn't need to wait until midnight to see logs on the server."
Why it did not work
hyp sink forcewas not obsolete - it was gated. The enrolling login writes a first-sync hold marker (LLP 0101), anddriver.tickreturns{ sinks: [], held: 'first_sync_hold' }before it consultsforce: true:The command then dropped
report.heldand printedno sinks instantiated; nothing to do- the opposite of the truth. Nothing could end the window early:first_sync_hold.jshad no clear function, and only the absolute deadline expiring removed the marker. On an evening enrollment the 4-hour floor rolls that deadline to the next day's 11:59pm, so "wait until midnight" could mean 28 hours.The design decision this overturns
LLP 0101
#no-releaserejected this verb by name, for three reasons. Two survive:hypaware-privacy) too slow to run in the meeting the hold was scoped to.#whichholds the attended lane while unattendedhyp joinforwards immediately, which is backwards for exactly this case.LLP 0069 R6 ("not forwarded, even once") is untouched. R6 forbids a silent first forward; an explicit confirmation naming the destination is the consent it exists to obtain. The daemon never clears the marker, so an unattended machine still waits out the full window.
The command
hyp sync [instance] [--yes] [--dry-run]replaceshyp sink forcerather than aliasing it - both were onedriver.tick({ force: true }), and a hidden second spelling preserves the confusion it was introduced to fix (the #399 doctrine).hyp sink maintainstays; snapshot expiration is not an export tick.--yesis the scripted bypass, no-TTY without it refuses -hyp purge's posture.http(s)URL, on-machine only on the evidence of a path; anything else prints bare rather than guessing, since the driver deliberately has no notion of which sinks leave the host (LLP 0101#hold).--dry-runprints the plan and never reaches the prompt.Onboarding surfaces
All three places that print the deadline now name the verb, and the deadline carries its time zone (ECMA-402 forbids mixing
timeZoneNamewithdateStyle/timeStyle, so the zone comes from a second formatter):remote_commands.jslogin messageto send it sooner, run: hyp syncwizard/index.jsprivacy narrationhyp syncand that it asks firststatus.jsheld line`hyp sync` sends it nowAlso
purgeandreport deletehad grown a copy each of the same y/N prompt; all three now sharesrc/core/cli/confirm.js, question text unchanged.Docs
LLP 0101
#no-releaseis amended with the original preserved as a quote. LLP 0100 R2 ("No export tick may run before the deadline") is amended with it - it was flatly false once this lands - and R1 now requires the zone and the release verb.Verification
npm test- 2754 pass, 12 new intest/core/sync-command.test.jscovering both refusal paths, both prompt tiers, marker lifecycle, and the plan's claimsnpm run typecheck- cleanlocal_parquet_export(updated to['sync', '--yes']),walkthrough_picker_to_first_query,status_diagnostics,package_bin_bootOne call for the reviewer
--yesreleases a live hold without prompting, consistent withpurge. That does mean a script can end a review window silently. If that is the wrong trade,--yescould refuse while held and require a distinct flag.🤖 Generated with Claude Code